<?php

defined('BASEPATH') or die('forbidden');

/**
 * IOS消息推送类
 * @author  Karson
 * @name    apns.php
 * @since   2013-6-14 19:23:40
 */
class Apns {

    public $config, $db;
    public $sslTimeout = 60;
    public $time;
    private $apnsData;
    private $showErrors = true;
    private $logErrors = true;
    private $logPath = 'apns.log';
    private $logMaxSize = 1048576; // max log size before it is truncated
    private $certificate = 'apns.pem';
    private $ssl = 'ssl://gateway.push.apple.com:2195';
    private $feedback = 'ssl://feedback.push.apple.com:2196';
    private $sandboxCertificate = 'apns-sandbox.pem'; // change this to your development certificate absolute path
    private $sandboxSsl = 'ssl://gateway.sandbox.push.apple.com:2195';
    private $sandboxFeedback = 'ssl://feedback.sandbox.push.apple.com:2196';
    private $message;
    private $sslStreams = array();

    function __construct($args = NULL) {
        $framework = &get_instance('controller');
        $this->config = &$framework->config;
        $this->db = &$framework->db;
        $this->cache = &$framework->cache;
        $this->time = time();
        $this->certificate = BASEPATH . 'commons/apns.pem';
        $this->sandboxCertificate = BASEPATH . 'commons/apns-sandbox.pem';
        $this->logPath = LOGPATH . "apns-" . date("Y-m-d") . ".log";
        $this->apnsData = array(
            'production' => array(
                'certificate' => $this->certificate,
                'ssl' => $this->ssl,
                'password' => '123456',
                'feedback' => $this->feedback
            ),
            'sandbox' => array(
                'certificate' => $this->sandboxCertificate,
                'ssl' => $this->sandboxSsl,
                'password' => '123456',
                'feedback' => $this->sandboxFeedback
            )
        );
    }

    /**
     * 注册应用的相关信息
     *
     * @param string $appname Application Name
     * @param string $appversion Application Version
     * @param string $deviceuid 40 charater unique user id of Apple device
     * @param string $devicetoken 64 character unique device token tied to device id
     * @param string $devicename User selected device name
     * @param string $devicemodel Modle of device 'iPhone' or 'iPod'
     * @param string $deviceversion Current version of device
     * @param string $pushbadge Whether Badge Pushing is Enabled or Disabled
     * @param string $pushalert Whether Alert Pushing is Enabled or Disabled
     * @param string $pushsound Whether Sound Pushing is Enabled or Disabled
     * @param string $clientid The clientid of the app for message grouping
     */
    public function register($params = array()) {
        extract($params);
        $clientid = NULL;
        if (!isset($appname) || strlen($appname) == 0)
            $this->_triggerError('Application Name must not be blank.', E_USER_ERROR);
        else if (!isset($appversion) || strlen($appversion) == 0)
            $this->_triggerError('Application Version must not be blank.', E_USER_ERROR);
        else if (!isset($deviceuid) || strlen($deviceuid) > 40)
            $this->_triggerError('Device ID may not be more than 40 characters in length.', E_USER_ERROR);
        else if (!isset($devicetoken) || strlen($devicetoken) != 64)
            $this->_triggerError('Device Token must be 64 characters in length.', E_USER_ERROR);
        else if (!isset($devicename) || strlen($devicename) == 0)
            $this->_triggerError('Device Name must not be blank.', E_USER_ERROR);
        else if (!isset($devicemodel) || strlen($devicemodel) == 0)
            $this->_triggerError('Device Model must not be blank.', E_USER_ERROR);
        else if (!isset($deviceversion) || strlen($deviceversion) == 0)
            $this->_triggerError('Device Version must not be blank.', E_USER_ERROR);
        else if (!isset($pushbadge) || $pushbadge != 'disabled' && $pushbadge != 'enabled')
            $this->_triggerError('Push Badge must be either Enabled or Disabled.', E_USER_ERROR);
        else if (!isset($pushalert) || $pushalert != 'disabled' && $pushalert != 'enabled')
            $this->_triggerError('Push Alert must be either Enabled or Disabled.', E_USER_ERROR);
        else if (!isset($pushsound) || $pushsound != 'disabled' && $pushsound != 'enabled')
            $this->_triggerError('Push Sount must be either Enabled or Disabled.', E_USER_ERROR);

        // store device for push notifications
        $sql = "INSERT INTO {pre}push_device
                    VALUES (
                            NULL,
                            '{$clientid}',
                            '{$appname}',
                            '{$appversion}',
                            '{$deviceuid}',
                            '{$devicetoken}',
                            '{$devicename}',
                            '{$devicemodel}',
                            '{$deviceversion}',
                            '{$pushbadge}',
                            '{$pushalert}',
                            '{$pushsound}',
                            'sandbox',
                            'active',
                            {$this->time},
                            {$this->time}
                    )
                    ON DUPLICATE KEY UPDATE
                    `devicetoken`='{$devicetoken}',
                    `devicename`='{$devicename}',
                    `devicemodel`='{$devicemodel}',
                    `deviceversion`='{$deviceversion}',
                    `pushbadge`='{$pushbadge}',
                    `pushalert`='{$pushalert}',
                    `pushsound`='{$pushsound}',
                    `status`='active',
                    `modified`={$this->time};";
        $this->db->query($sql);
    }

    public function fetch() {
        // only send one message per user... oldest message first
        $sql = "SELECT
                        am.`pid`,
                        am.`message`,
                        ad.`devicetoken`,
                        ad.`development`
                FROM {pre}push_message am
                LEFT JOIN {pre}push_device ad ON (ad.`pid` = am.`fk_device` AND ad.`clientid` = am.`clientid`)
                WHERE am.`status`='queued'
                        AND am.`delivery` <= {$this->time}
                        AND ad.`status`='active'
                GROUP BY am.`fk_device`
                ORDER BY am.`created` ASC
                LIMIT 100;";

        $this->_iterateMessages($sql);
    }

    public function flush() {
        // only send one message per user... oldest message first
        $sql = "SELECT
                            am.`pid`,
                            am.`message`,
                            ad.`devicetoken`,
                            ad.`development`
                    FROM {pre}push_message am
                    LEFT JOIN {pre}push_device ad ON (ad.`pid` = am.`fk_device` AND ad.`clientid` = am.`clientid`)
                    WHERE am.`status`='queued'
                            AND am.`delivery` <= {$this->time}
                            AND ad.`status`='active'
                    ORDER BY am.`created` ASC
                    LIMIT 100;";

        $this->_iterateMessages($sql);
    }

    private function _iterateMessages($sql) {
        $this->db->query($sql, "it");
        while ($row = $this->db->get_array("it")) {
            $pid = $this->db->prepare($row['pid']);
            $message = stripslashes($this->db->prepare($row['message']));
            $token = $this->db->prepare($row['devicetoken']);
            $development = $this->db->prepare($row['development']);

            // Connect the socket the first time it's needed.
            if (!isset($this->sslStreams[$development])) {
                $this->_connectSSLSocket($development);
            }
            $this->_pushMessage($pid, $message, $token, $development);
        }
        // Close streams and check feedback service
        foreach ($this->sslStreams as $key => $socket) {
            $this->_closeSSLSocket($key);
            //$this->_checkFeedback($key);
        }
    }

    private function _connectSSLSocket($development) {
        $ctx = stream_context_create();
        stream_context_set_option($ctx, 'ssl', 'local_cert', $this->apnsData[$development]['certificate']);
        stream_context_set_option($ctx, 'ssl', 'passphrase', $this->apnsData[$development]['password']);
        $this->sslStreams[$development] = stream_socket_client($this->apnsData[$development]['ssl'], $error, $errorString, $this->sslTimeout, (STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT), $ctx);
        if (!$this->sslStreams[$development]) {
            $this->_triggerError("Failed to connect to APNS: {$error} {$errorString}.");
            unset($this->sslStreams[$development]);
            return false;
        }
        return $this->sslStreams[$development];
    }

    private function _closeSSLSocket($development) {
        if (isset($this->sslStreams[$development])) {
            fclose($this->sslStreams[$development]);
            unset($this->sslStreams[$development]);
        }
    }

    private function _pushMessage($pid, $message, $token, $development) {
        if (strlen($pid) == 0)
            $this->_triggerError('Missing message pid.', E_USER_ERROR);
        if (strlen($message) == 0)
            $this->_triggerError('Missing message.', E_USER_ERROR);
        if (strlen($token) == 0)
            $this->_triggerError('Missing message token.', E_USER_ERROR);
        if (strlen($development) == 0)
            $this->_triggerError('Missing development status.', E_USER_ERROR);

        $fp = false;
        if (isset($this->sslStreams[$development])) {
            $fp = $this->sslStreams[$development];
        }

        if (!$fp) {
            $this->_pushFailed($pid);
            $this->_triggerError("A connected socket to APNS wasn't available.");
        } else {
            $expiry = time() + 120; // 2 minute validity hard coded!
            $msg = chr(1) . pack("N", $pid) . pack("N", $expiry) . pack("n", 32) . pack('H*', $token) . pack("n", strlen($message)) . $message;

            $fwrite = fwrite($fp, $msg);
            if (!$fwrite) {
                $this->_pushFailed($pid);
                $this->_triggerError("Failed writing to stream.", E_USER_ERROR);
                $this->_closeSSLSocket($development);
            } else {
                $tv_sec = 1;
                $tv_usec = null; // Timeout. 1 million micro seconds = 1 second
                $r = array($fp);
                $we = null; // Temporaries. "Only variables can be passed as reference."
                $numChanged = @stream_select($r, $we, $we, $tv_sec, $tv_usec);
                if (false === $numChanged) {
                    $this->_triggerError("Failed selecting stream to read.", E_USER_ERROR);
                } else if ($numChanged > 0) {
                    $command = ord(fread($fp, 1));
                    $status = ord(fread($fp, 1));
                    $identifier = implode('', unpack("N", fread($fp, 4)));
                    $statusDesc = array(
                        0 => 'No errors encountered',
                        1 => 'Processing error',
                        2 => 'Missing device token',
                        3 => 'Missing topic',
                        4 => 'Missing payload',
                        5 => 'Invalid token size',
                        6 => 'Invalid topic size',
                        7 => 'Invalid payload size',
                        8 => 'Invalid token',
                        255 => 'None (unknown)',
                    );
                    $this->_triggerError("APNS responded with command($command) status($status) pid($identifier).", E_USER_NOTICE);

                    if ($status > 0) {
                        // $identifier == $pid
                        $this->_pushFailed($pid);
                        $desc = isset($statusDesc[$status]) ? $statusDesc[$status] : 'Unknown';
                        $this->_triggerError("APNS responded with error for pid($identifier). status($status: $desc)", E_USER_ERROR);
                        // The socket has also been closed. Cause reopening in the loop outside.
                        $this->_closeSSLSocket($development);
                    } else {
                        // Apple docs state that it doesn't return anything on success though
                        $this->_pushSuccess($pid);
                    }
                } else {
                    $this->_pushSuccess($pid);
                }
            }
        }
    }

    private function _checkFeedback($development) {
        $ctx = stream_context_create();
        stream_context_set_option($ctx, 'ssl', 'local_cert', $this->apnsData[$development]['certificate']);
        stream_context_set_option($ctx, 'ssl', 'passphrase', $this->apnsData[$development]['password']);
        stream_context_set_option($ctx, 'ssl', 'verify_peer', false);
        $fp = stream_socket_client($this->apnsData[$development]['feedback'], $error, $errorString, 60, (STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT), $ctx);
        if (!$fp)
            $this->_triggerError("Failed to connect to device: {$error} {$errorString}.");
        while ($devcon = fread($fp, 38)) {
            $arr = unpack("H*", $devcon);
            $rawhex = trim(implode("", $arr));
            $token = substr($rawhex, 12, 64);
            if (!empty($token)) {
                $this->_unregisterDevice($token);
                $this->_triggerError("Unregistering Device Token: {$token}.");
            }
        }
        fclose($fp);
    }

    private function _unregisterDevice($token) {
        $sql = "UPDATE {pre}push_device SET `status`='uninstalled' WHERE `devicetoken`='{$token}' LIMIT 1;";
        $this->db->query($sql);
    }

    private function _pushSuccess($pid) {
        $sql = "UPDATE {pre}push_message SET `status`='delivered' WHERE `pid`={$pid} LIMIT 1;";
        $this->db->query($sql);
    }

    private function _pushFailed($pid) {
        $sql = "UPDATE {pre}push_message SET `status`='failed' WHERE `pid`={$pid} LIMIT 1;";
        $this->db->query($sql);
    }

    /**
     * 根据主键开始发送消息
     *
     * @param mixed $fk_device Foreign Key, or Array of Foreign Keys to the device you want to send a message to.
     * @param string $delivery Possible future date to send the message.
     * @access public
     */
    public function newMessage($fk_device = NULL, $delivery = NULL, $clientId = NULL) {
        if (isset($this->message)) {
            unset($this->message);
            $this->_triggerError('An existring message already created but not delivered. The previous message has been removed. Use queueMessage() to complete a message.');
        }
        // If no device is specified then that means we sending a message to all.
        if (is_null($fk_device)) {
            $sql = "SELECT `pid` FROM {pre}push_device WHERE `status`='active'";

            // Only to a set of client?
            if (!is_null($clientId))
                $sql .= " AND `clientid` = '{$this->db->prepare($clientId)}'";

            $ids = array();
            $this->db->query($sql);
            while ($row = $this->db->get_array())
                $ids[] = $row['pid'];

            $fk_device = $ids;
        }

        $this->message = array();
        $this->message['aps'] = array();
        $this->message['aps']['clientid'] = $clientId;
        $this->message['send']['to'] = $fk_device;
        $this->message['send']['when'] = $delivery;
    }

    /**
     * 根据deviceid中发送消息
     * Start a New Message. Like newMessage, but takes the deviceUId instead of fk_device.
     * Actually fetches the pid from the db and then calls the plain newMessage.
     *
     * @param mixed $deviceUId The DeviceUId you want to send the message to.
     * @param string $delivery Possible future date to send the message.
     * @access public
     */
    public function newMessageByDeviceUId($deviceUId = NULL, $delivery = NULL, $clientId = NULL) {
        $sql = "SELECT `pid` FROM {pre}push_device WHERE `deviceuid`='$deviceUId'";
        $row = $this->db->one($sql);
        if ($row)
            $this->newMessage($row["pid"], $delivery, $clientId);
    }

    /**
     * 将消息发送到队列
     * @access public
     */
    public function queueMessage() {
        // check to make sure a message was created
        if (!isset($this->message))
            $this->_triggerError('You cannot Queue a message that has not been created. Use newMessage() to create a new message.');

        // loop through possible users
        $to = $this->message['send']['to'];
        $when = $this->message['send']['when'];
        $clientId = is_null($this->message['aps']['clientid']) ? null : $this->db->prepare($this->message['aps']['clientid']);
        $list = (is_array($to)) ? $to : array($to);
        unset($this->message['send']);

        // Lets make sure that the recipients are integers. If not then just remove
        foreach ($list as $key => $val)
            if (!is_numeric($val)) {
                $this->_triggerError("TO id was not an integer: $val.");
                unset($list[$key]);
            }

        // No recipients left?
        if (empty($list))
            $this->_triggerError('No valid recipient was provided.');

        // Get the devices.
        // fetch the users id and check to make sure they have certain notifications enabled before trying to send anything to them.
        $sql = "SELECT `pid`, `pushbadge`, `pushalert`, `pushsound`
                                FROM {pre}push_device
                                WHERE `pid` IN (" . implode(', ', $list) . ")
                                        AND `status`='active'" . (is_null($clientId) ? '' : "
                                        AND `clientid` = '{$clientId}'");

        $this->db->query($sql, "list");

        if ($this->db->num_rows('list') == 0)
            $this->_triggerError('This user does not exist in the database. Message will not be delivered.');

        while ($row = $this->db->get_array("list")) {
            // Device id.
            $deviceid = $row['pid'];
            // Get the push settings.
            $pushbadge = $row['pushbadge'];
            $pushalert = $row['pushalert'];
            $pushsound = $row['pushsound'];

            // has user disabled messages?
            if ($pushbadge == 'disabled' && $pushalert == 'disabled' && $pushsound == 'disabled') {
                $this->_triggerError('This user has disabled all push notifications. Message will not be delivered.');
                continue;
            }

            // make temp copy of message so we can cut out stuff this user may not get
            $usermessage = $this->message;

            // only send badge if user will get it
            if ($pushbadge == 'disabled') {
                $this->_triggerError('This user has disabled Push Badge Notifications, Badge will not be delivered.');
                unset($usermessage['aps']['badge']);
            }

            // only send alert if user will get it
            if ($pushalert == 'disabled') {
                $this->_triggerError('This user has disabled Push Alert Notifications, Alert will not be delivered.');
                unset($usermessage['aps']['alert']);
            }

            // only send sound if user will get it
            if ($pushsound == 'disabled') {
                $this->_triggerError('This user has disabled Push Sound Notifications, Sound will not be delivered.');
                unset($usermessage['aps']['sound']);
            }

            if (is_null($usermessage['aps']['clientid'])) {
                unset($usermessage['aps']['clientid']);
            }

            if (empty($usermessage['aps'])) {
                unset($usermessage['aps']);
            }

            $fk_device = $this->db->prepare($deviceid);
            $message = json_encode($usermessage);
            $message = $this->db->prepare($message);
            $message = str_replace('\\\ue', '\\ue', $message);
            $delivery = (!empty($when)) ? strtotime($when) : $this->time;

            $sql = "INSERT INTO {pre}push_message VALUES (
                                NULL,
                                '{$clientId}',
                                '{$fk_device}',
                                '{$message}',
                                {$delivery},
                                'queued',
                                {$this->time},
                                {$this->time}
                        );";
            $this->db->query($sql);
            unset($usermessage);
        }
        unset($this->message);
    }

    /**
     * 添加消息的内容
     */
    public function addMessageAlert($alert = NULL, $actionlockey = NULL, $lockey = NULL, $locargs = NULL) {
        if (!$this->message)
            $this->_triggerError('Must use newMessage() before calling this method.', E_USER_ERROR);
        if (isset($this->message['aps']['alert'])) {
            unset($this->message['aps']['alert']);
            $this->_triggerError('An existring alert was already created but not delivered. The previous alert has been removed.');
        }
        switch (true) {
            case (!empty($alert) && empty($actionlockey) && empty($lockey) && empty($locargs)):
                if (!is_string($alert))
                    $this->_triggerError('Invalid Alert Format. See documentation for correct procedure.', E_USER_ERROR);
                $this->message['aps']['alert'] = (string) $alert;
                break;

            case (!empty($alert) && !empty($actionlockey) && empty($lockey) && empty($locargs)):
                if (!is_string($alert))
                    $this->_triggerError('Invalid Alert Format. See documentation for correct procedure.', E_USER_ERROR);
                else if (!is_string($actionlockey))
                    $this->_triggerError('Invalid Action Loc Key Format. See documentation for correct procedure.', E_USER_ERROR);
                $this->message['aps']['alert']['body'] = (string) $alert;
                $this->message['aps']['alert']['action-loc-key'] = (string) $actionlockey;
                break;

            case (empty($alert) && empty($actionlockey) && !empty($lockey) && !empty($locargs)):
                if (!is_string($lockey))
                    $this->_triggerError('Invalid Loc Key Format. See documentation for correct procedure.', E_USER_ERROR);
                $this->message['aps']['alert']['loc-key'] = (string) $lockey;
                $this->message['aps']['alert']['loc-args'] = $locargs;
                break;

            default:
                $this->_triggerError('Invalid Alert Format. See documentation for correct procedure.', E_USER_ERROR);
                break;
        }
    }

    /**
     * 添加消息的提示条数
     *
     * @param int $number
     * @access public
     */
    public function addMessageBadge($number = NULL) {
        if (!$this->message)
            $this->_triggerError('Must use newMessage() before calling this method.', E_USER_ERROR);
        if ($number) {
            if (isset($this->message['aps']['badge']))
                $this->_triggerError('Message Badge has already been created. Overwriting with ' . $number . '.');
            $this->message['aps']['badge'] = (int) $number;
        }
    }

    /**
     * 添加消息的自定义参数
     *
     * @param string $key Name of Custom Object you want to pass back to your iPhone App
     * @param mixed $value Mixed Value you want to pass back.  Can be int, bool, string, or array.
     * @access public
     */
    public function addMessageCustom($key = NULL, $value = NULL) {
        if (!$this->message)
            $this->_triggerError('Must use newMessage() before calling this method.', E_USER_ERROR);
        if (!empty($key) && !empty($value)) {
            if (isset($this->message[$key])) {
                unset($this->message[$key]);
                $this->_triggerError('This same Custom Key already exists and has not been delivered. The previous values have been removed.');
            }
            if (!is_string($key))
                $this->_triggerError('Invalid Key Format. Key must be a string. See documentation for correct procedure.', E_USER_ERROR);
            $this->message[$key] = $value;
        }
    }

    /**
     * 添加一个提示的声音
     * @param string $sound Name of sound file in your Resources Directory
     * @access public
     */
    public function addMessageSound($sound = NULL) {
        if (!$this->message)
            $this->_triggerError('Must use newMessage() before calling this method.', E_USER_ERROR);
        if ($sound) {
            if (isset($this->message['aps']['sound']))
                $this->_triggerError('Message Sound has already been created. Overwriting with ' . $sound . '.');
            $this->message['aps']['sound'] = (string) $sound;
        }
    }

    /**
     * 发送队列中的消息到客户端
     */
    public function processQueue() {
        $this->fetch();
    }

    /**
     * 记录或打印错误日志
     */
    private function _triggerError($error, $type = E_USER_NOTICE) {
        $backtrace = debug_backtrace();
        $backtrace = array_reverse($backtrace);
        $error .= "\n";
        $i = 1;
        foreach ($backtrace as $errorcode) {
            $file = ($errorcode['file'] != '') ? "-> File: " . basename($errorcode['file']) . " (line " . $errorcode['line'] . ")" : "";
            $error .= "\n\t" . $i . ") " . $errorcode['class'] . "::" . $errorcode['function'] . " {$file}";
            $i++;
        }
        $error .= "\n\n";
        if ($this->logErrors) {
            if (filesize($this->logPath) > $this->logMaxSize)
                $fh = fopen($this->logPath, 'w');
            else
                $fh = fopen($this->logPath, 'a');
            fwrite($fh, $error);
            fclose($fh);
        }
        if ($this->showErrors)
            trigger_error($error, $type);
    }

}